relax

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 6, 2026 License: MIT Imports: 2 Imported by: 0

README

Relax

CI codecov Go Report Card pkg.go.dev Go Version License

Don't panic - just relax.

relax is a small library for panic-based error propagation inside trusted internal call chains. It is not a replacement for Go's error model — it is a tool for the cases where repeated error forwarding through intermediate layers adds noise without adding value.

Installation

go get github.com/arpaad/relax
import "github.com/arpaad/relax"

Quick Start

package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

type User struct {
	Name string
}

func fetchUser(id int) (User, error) {
	return User{}, errors.New("database unavailable")
}

func HandleRequest(id int) error {
	return relax.CheckError(func() error {
		user := relax.FailOnError(fetchUser(id))
		fmt.Println(user.Name)
		return nil
	})
}

Inside the boundary: use FailOnError* or FailWith. At the boundary that should return a normal Go error: use a Check* helper.

The Problem This Solves

Go's explicit error handling is one of the language's strengths at API boundaries. But in a deep internal call chain like A → B → C → D → E, when only A handles the failure that E produces, every intermediate layer ends up doing nothing except forwarding:

value, err := next()
if err != nil {
	return err  // B, C, D all do this — none of them can do anything else
}

This is the boilerplate the Go team acknowledged but deliberately left to library authors to address. relax fills that gap for trusted internal layers.

With relax, the same chain stays linear:

func A() error {
	return relax.CheckFailer(B)
}

func B() { C() }

func C() { D() }

func D() {
	relax.FailWith(E())
}

func E() error {
	return errors.New("storage unavailable")
}

B and C do not participate in forwarding a failure that only A intends to handle. If E returned (T, error) instead, D would use relax.FailOnError(E()).

Only Failer panics are recovered at the boundary. Programmer errors and runtime faults — nil pointer dereferences, index out of range — still propagate unchanged. The recovery boundary is explicit and visible in the code, not inferred from the runtime.

Flow Overview

The diagram below shows the core idea: deep internal code fails once, the failure unwinds through trusted layers unchanged, and the outer boundary converts it back into a normal Go error.

sequenceDiagram
	participant Caller
	participant A as Boundary A
	participant B as Layer B
	participant C as Layer C
	participant D as Layer D
	participant E as Layer E

	Caller->>A: call
	A->>B: call
	B->>C: call
	C->>D: call
	D->>E: call
	E-->>D: return error
	D->>D: FailWith(err)
	D--xC: panic(Failer)
	C--xB: panic(Failer)
	B--xA: panic(Failer)
	A->>A: CheckFailer / CheckError / CheckResult
	A-->>Caller: return error

Public API

Failure Propagation

Use these helpers inside trusted internal layers when a failure should immediately unwind to an outer boundary.

func FailWith(err error, keyVals ...any)

func FailOnError[T any](v T, err error) T
func FailOnError2[T1, T2 any](v1 T1, v2 T2, err error) (T1, T2)
func FailOnError3[T1, T2, T3 any](v1 T1, v2 T2, v3 T3, err error) (T1, T2, T3)

FailWith throws an error immediately. The FailOnError* helpers are convenience wrappers for the common Go shapes that already return an error.

Recovery Boundaries

Use these helpers at the edge of the internal call chain, where panic-based propagation should be converted back into ordinary Go errors.

func CheckFailer(fn func()) error
func CheckError(fn func() error) error

func CheckValue[T any](fn func() T) (T, error)
func CheckValue2[T1, T2 any](fn func() (T1, T2)) (T1, T2, error)
func CheckValue3[T1, T2, T3 any](fn func() (T1, T2, T3)) (T1, T2, T3, error)

func CheckResult[T any](fn func() (T, error)) (T, error)
func CheckResult2[T1, T2 any](fn func() (T1, T2, error)) (T1, T2, error)
func CheckResult3[T1, T2, T3 any](fn func() (T1, T2, T3, error)) (T1, T2, T3, error)

func HandleFailer(fn func(), onError func(error))
Supported Function Shapes

At the propagation point — used inside a trusted layer; returns values on success, panics with a Failer on error:

Call shape Helper Returns on success On error
(T, error) FailOnError T panics
(T1, T2, error) FailOnError2 T1, T2 panics
(T1, T2, T3, error) FailOnError3 T1, T2, T3 panics

At the recovery boundary — wraps a closure; catches Failer panics and converts them into a returned error:

Wraps Helper Returns
func() CheckFailer error
func() error CheckError error
func() T CheckValue T, error
func() (T1, T2) CheckValue2 T1, T2, error
func() (T1, T2, T3) CheckValue3 T1, T2, T3, error
func() (T, error) CheckResult T, error
func() (T1, T2, error) CheckResult2 T1, T2, error
func() (T1, T2, T3, error) CheckResult3 T1, T2, T3, error
func() + func(error) HandleFailer — (calls the handler)

Support stops at three non-error return values. For four or more, wrap the values in a struct and use CheckValue or CheckResult.

Utilities
type Failer struct {
	Err     error
	Context map[string]any
}

func ConvertToFailer(err error) Failer
func IsFailer(err error) bool

Most application code does not need to work with Failer directly. It is primarily useful when you want the structured context attached to a failure.

Adding Context

FailWith can attach structured metadata to the failure as it unwinds:

if err := saveUser(user); err != nil {
	relax.FailWith(err,
		"user_id", user.ID,
		"operation", "save_user",
	)
}

If the error is already a Failer, the context is merged instead of wrapping it again. Keys are stringified using fmt.Sprint; the last write wins for a given key. To avoid collisions when multiple layers annotate the same failure, use namespaced keys:

relax.FailWith(err, "db.operation", "save")
relax.FailWith(err, "http.operation", "POST /users")

Working With Errors

You do not need to know anything about relax.Failer to access the original domain error. Failer implements Unwrap(), so errors.As and errors.Is work against the inner error as usual:

type ValidationError struct {
	Field string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("invalid %s", e.Field)
}

_, err := relax.CheckValue(func() string {
	relax.FailWith(&ValidationError{Field: "email"})
	return ""
})

var target *ValidationError
if errors.As(err, &target) {
	fmt.Println(target.Field) // email
}

You only need Failer itself when you want the structured Context attached to a failure.

Common composition patterns:

fmt.Errorf with %w — add context while keeping the error chain intact:

relax.FailWith(fmt.Errorf("loading user %d: %w", id, err))

Sentinel errors with errors.Is — works automatically through Failer.Unwrap():

var ErrNotFound = errors.New("not found")

relax.FailWith(ErrNotFound)
...
errors.Is(err, ErrNotFound) // true at the boundary

errors.Join (Go 1.20+) — when multiple errors occur inside a boundary:

relax.FailWith(errors.Join(errValidation, errDatabase))

Stack traces are not captured by relax — this is intentional. Capture is expensive and belongs in your logger or tracing layer, not in the error itself. If you need traces attached to the error value, wrap before passing to FailWith:

relax.FailWith(fmt.Errorf("operation failed: %w", err))

Goroutines

Make the goroutine boundary explicit and use HandleFailer inside the go statement:

go relax.HandleFailer(func() {
	user := relax.FailOnError(loadUser(id))
	syncUser(user)
}, func(err error) {
	log.Printf("worker failed: %v", err)
})

HandleFailer recovers only Failer panics and forwards them to onError. Any non-Failer panic is re-panicked unchanged — programmer bugs and runtime faults still fail loudly. Passing a nil onError panics immediately.

flowchart TD
	Start["go HandleFailer(fn, onError)"] --> Run[run worker code]
	Run -->|success| Exit[goroutine exits]
	Run -->|FailWith / FailOnError| Panic[panic Failer]
	Panic --> Recover[HandleFailer recovers]
	Recover --> Forward["onError(err)"]
	Run -->|non-Failer panic| Repanic[re-panic unchanged]

Performance

Measured on AMD Ryzen AI 9 HX 370, Go 1.25, linux/amd64.

relax has a fixed, constant cost: one defer on the happy path, one panic+recover on the error path. Neither scales with depth or complexity.

Happy path

The defer at the boundary costs ≈ 10 ns. Explicit propagation accumulates (T, error) return overhead at each frame crossing; relax does not. As the chain grows, explicit gets more expensive while relax stays flat — they meet at depth ≈ 8.

Depth Explicit relax Δ
1 6 ns 14 ns +8 ns
5 17 ns 20 ns +3 ns
8 25 ns 24 ns ≈ 0
10 31 ns 26 ns −5 ns
Error path

panic+recover costs ≈ 400–700 ns · 48 B · 2 allocs per triggered failure. The panic mechanism dominates — depth adds very little on top. The 2 allocations (Failer struct + boxed interface value) happen once per error, not once per frame.

Depth Explicit relax
1 6 ns · 0 B 434 ns · 48 B · 2 allocs
5 16 ns · 0 B 573 ns · 48 B · 2 allocs
10 30 ns · 0 B 735 ns · 48 B · 2 allocs

With 4 context key-value pairs attached to the failure: ≈ 1030 ns · 400 B · 8 allocs.

Conclusion

Performance is not a reason to avoid relax. On the happy path the overhead is a single constant that shrinks to zero as the chain grows — at depth 8 it is already unmeasurable. On the error path the extra ≈ 400–700 ns is real but irrelevant in practice: a database round-trip costs 100 µs–10 ms, two to three orders of magnitude more. The overhead only matters in a hot loop that triggers errors at high frequency with no I/O — which is not the use case relax is designed for.

Run go test -bench=. -benchmem ./... to measure on your own hardware.

When It Fits Well

relax is a good fit for:

  • service-layer orchestration
  • request and command pipelines
  • background jobs and workers
  • CLI execution flows
  • deep internal call chains where the middle layers only forward failures

It is usually a poor fit for:

  • exported public APIs
  • low-level reusable libraries consumed by others
  • hot performance-critical loops
  • ordinary control flow where explicit error handling is clearer

Design Guarantees

  • Only Failer panics are recovered.
  • Non-Failer panics propagate unchanged.
  • The original error is preserved through Unwrap().
  • Existing Failer values are never double-wrapped.
  • errors.Is and errors.As continue to work normally.
  • FailWith(nil) is always a no-op.
  • HandleFailer with a nil onError panics immediately.

Testing

go test ./...
go test -v ./...
go test -bench=. -benchmem ./...

Agent Skill

A self-contained AI skill lives in .github/skills/relax/ and includes the full API surface so agents can generate correct code without access to this source. Copy the folder to use it in other projects.

License

MIT - see LICENSE.

Documentation

Overview

Package relax provides small helpers for structured, typed panic-based propagation inside well-defined internal boundaries.

Use `FailWith` and `FailOnError*` to escalate errors with optional key/value context without changing function signatures. Use `Check*` helpers at boundary points to recover `Failer` panics back into normal `error` values.

See the package examples in `example_test.go` for runnable usage samples.

Example (ErrorsAs)
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

type userNotFoundError struct {
	ID int
}

func (e *userNotFoundError) Error() string {
	return fmt.Sprintf("user %d not found", e.ID)
}

func main() {
	_, err := relax.CheckValue(func() string {
		relax.FailWith(&userNotFoundError{ID: 42})
		return ""
	})

	var target *userNotFoundError
	fmt.Println(errors.As(err, &target))
	fmt.Println(target.ID)

}
Output:
true
42
Example (QuickStart)
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	err := relax.CheckFailer(func() {
		relax.FailWith(errors.New("database unavailable"))
	})

	fmt.Println(err)

}
Output:
database unavailable
Example (RealisticServiceFlow)
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	E := func() error {
		return errors.New("storage unavailable")
	}

	D := func() {
		relax.FailWith(E())
	}

	C := func() { D() }
	B := func() { C() }
	A := func() error { return relax.CheckFailer(B) }

	fmt.Println(A())

}
Output:
storage unavailable

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func CheckError

func CheckError(fn func() error) (err error)

CheckError executes fn which returns only an error.

If fn returns a non-nil error, it is returned unchanged. If fn panics with a Failer, the panic is recovered and converted into an error. Any other panic is re-panicked unchanged.

CheckError is used for command-style functions where no value is returned, but execution may still fail.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	loadUser := func(id int) (string, error) {
		return "", errors.New("user not found")
	}

	err := relax.CheckError(func() error {
		_ = relax.FailOnError(loadUser(99))
		return nil
	})

	fmt.Println(err)

}
Output:
user not found

func CheckFailer

func CheckFailer(fn func()) (err error)

CheckFailer executes fn and returns any error produced during execution.

If fn panics with a Failer, the panic is recovered and converted into an error. Any other panic is re-panicked unchanged.

CheckFailer is intended for functions that do not return a value but may fail, and where failures should be handled as errors instead of panics.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	err := relax.CheckFailer(func() {
		relax.FailWith(errors.New("something failed"))
	})

	fmt.Println(err)

}
Output:
something failed

func CheckResult

func CheckResult[T any](fn func() (T, error)) (result T, err error)

CheckResult executes fn which returns a value and an error.

If fn returns a non-nil error, it is returned unchanged. If fn panics with a Failer, the panic is recovered and converted into an error. Any other panic is re-panicked unchanged.

CheckResult is used when the underlying function already follows Go's (T, error) convention but still needs panic-to-error boundary protection.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	value, err := relax.CheckResult(func() (int, error) {
		if true {
			relax.FailWith(errors.New("calculation failed"))
		}

		return 42, nil
	})

	fmt.Println(value)
	fmt.Println(err)

}
Output:
0
calculation failed

func CheckResult2

func CheckResult2[T1, T2 any](fn func() (T1, T2, error)) (result1 T1, result2 T2, err error)

CheckResult2 executes fn which returns two values and an error.

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	left, right, err := relax.CheckResult2(func() (int, string, error) {
		return 7, "ok", nil
	})

	fmt.Println(left)
	fmt.Println(right)
	fmt.Println(err == nil)

}
Output:
7
ok
true

func CheckResult3

func CheckResult3[T1, T2, T3 any](fn func() (T1, T2, T3, error)) (result1 T1, result2 T2, result3 T3, err error)

CheckResult3 executes fn which returns three values and an error.

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	major, minor, patch, err := relax.CheckResult3(func() (int, int, int, error) {
		return 1, 2, 3, nil
	})

	fmt.Println(major)
	fmt.Println(minor)
	fmt.Println(patch)
	fmt.Println(err == nil)

}
Output:
1
2
3
true

func CheckValue

func CheckValue[T any](fn func() T) (result T, err error)

CheckValue executes fn and converts its execution into a checked call.

If fn completes successfully, its return value is returned and err is nil. If fn panics with a Failer, the panic is recovered and converted into an error. Any other panic is re-panicked unchanged.

CheckValue is intended for boundary layers (such as HTTP handlers or goroutine entry points) where panics of type Failer should be translated into errors.

Example

Because Failer implements Unwrap, callers can inspect the returned error directly with errors.As without knowing about relax.Failer.

package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func fetchUser(id int) (string, error) {
	return "", errors.New("database unavailable")
}

func main() {
	profile, err := relax.CheckValue(func() string {
		return relax.FailOnError(fetchUser(42))
	})

	fmt.Println(profile == "")
	fmt.Println(err)

}
Output:
true
database unavailable

func CheckValue2

func CheckValue2[T1, T2 any](fn func() (T1, T2)) (result1 T1, result2 T2, err error)

CheckValue2 executes fn and converts its execution into a checked call for two returned values.

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	left, right, err := relax.CheckValue2(func() (int, string) {
		return 7, "ok"
	})

	fmt.Println(left)
	fmt.Println(right)
	fmt.Println(err == nil)

}
Output:
7
ok
true

func CheckValue3

func CheckValue3[T1, T2, T3 any](fn func() (T1, T2, T3)) (result1 T1, result2 T2, result3 T3, err error)

CheckValue3 executes fn and converts its execution into a checked call for three returned values.

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	major, minor, patch, err := relax.CheckValue3(func() (int, int, int) {
		return 1, 2, 3
	})

	fmt.Println(major)
	fmt.Println(minor)
	fmt.Println(patch)
	fmt.Println(err == nil)

}
Output:
1
2
3
true

func FailOnError

func FailOnError[T any](v T, err error) T

FailOnError returns `v` if `err == nil`; otherwise it throws the error via `FailWith(err)`.

This reduces error-forwarding boilerplate inside internal call chains where panic-based propagation is acceptable. Prefer explicit returns in public APIs.

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	loadProfile := func() (string, error) {
		return "alice", nil
	}

	profile := relax.FailOnError(loadProfile())

	fmt.Println(profile)

}
Output:
alice

func FailOnError2

func FailOnError2[T1, T2 any](v1 T1, v2 T2, err error) (T1, T2)

FailOnError2 returns v1 and v2 if err == nil; otherwise it throws the error via FailWith(err).

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	loadName := func() (string, string, error) {
		return "Ada", "Lovelace", nil
	}

	first, last := relax.FailOnError2(loadName())

	fmt.Println(first)
	fmt.Println(last)

}
Output:
Ada
Lovelace

func FailOnError3

func FailOnError3[T1, T2, T3 any](v1 T1, v2 T2, v3 T3, err error) (T1, T2, T3)

FailOnError3 returns v1, v2 and v3 if err == nil; otherwise it throws the error via FailWith(err).

Example
package main

import (
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	loadVersion := func() (int, int, int, error) {
		return 1, 2, 3, nil
	}

	major, minor, patch := relax.FailOnError3(loadVersion())

	fmt.Println(major)
	fmt.Println(minor)
	fmt.Println(patch)

}
Output:
1
2
3

func FailWith

func FailWith(err error, keyVals ...any)

FailWith panics with a `Failer` that wraps `err`.

If `err` is already a `Failer` (value or pointer) it will be re-panicked directly; in that case any provided key/value pairs are merged into the existing `Failer.Context`. If `err` is nil, `FailWith` is a no-op.

The `keyVals` are interpreted as alternating key, value pairs. Keys are stringified using `fmt.Sprint`; an odd number of `keyVals` is allowed and the final key will be assigned a `nil` value.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	err := relax.CheckFailer(func() {
		relax.FailWith(errors.New("save failed"))
	})

	var failer relax.Failer
	if errors.As(err, &failer) {
		fmt.Println(failer.Err)
	}

}
Output:
save failed
Example (Context)
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	err := relax.CheckFailer(func() {
		relax.FailWith(errors.New("save failed"),
			"user_id", 42,
			"operation", "save_user",
		)
	})

	var failer relax.Failer
	if errors.As(err, &failer) {
		fmt.Println(failer.Err)
		fmt.Println(failer.Context["user_id"])
		fmt.Println(failer.Context["operation"])
	}

}
Output:
save failed
42
save_user
Example (Function)
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	badFun := func() error {
		return errors.New("Failer error")
	}

	err := relax.CheckFailer(func() {
		relax.FailWith(badFun())
	})

	var failer relax.Failer
	if errors.As(err, &failer) {
		fmt.Println(failer.Err)
	}

}
Output:
Failer error

func HandleFailer

func HandleFailer(fn func(), onError func(error))

HandleFailer executes fn inside a CheckFailer boundary and forwards any recovered Failer as a standard error to onError.

HandleFailer is useful for explicit failure-handling boundaries where panic-based propagation is used internally, but failures must be handled locally instead of escaping the current execution flow.

Only panics carrying a Failer are recovered. Any other panic is re-panicked unchanged.

onError must not be nil. Passing a nil handler causes HandleFailer to panic immediately.

Example:

relax.HandleFailer(func() {
    process()
}, logger.Error)

In this example, if process panics with a Failer, the panic is recovered and forwarded to logger.Error as a standard error. Any other panic from process is re-panicked unchanged. Could be safely used at the entry point of a goroutine, worker loop, or background job:

go relax.HandleFailer(process, logger.Error)

HandleFailer is especially useful at goroutine entry points, worker loops, background jobs, and asynchronous execution boundaries.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	relax.HandleFailer(func() {
		relax.FailWith(errors.New("worker failed"))
	}, func(err error) {
		fmt.Println(err)
	})

}
Output:
worker failed
Example (Goroutine)
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	done := make(chan struct{})

	go relax.HandleFailer(func() {
		relax.FailWith(errors.New("worker failed"))
	}, func(err error) {
		fmt.Println(err)
		close(done)
	})

	<-done

}
Output:
worker failed

func IsFailer

func IsFailer(err error) bool

IsFailer reports whether `err` is a `Failer` value or wraps a `Failer`.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	failer := relax.ConvertToFailer(errors.New("failure"))

	fmt.Println(relax.IsFailer(failer))
	fmt.Println(relax.IsFailer(errors.New("normal error")))

}
Output:
true
false

Types

type Failer

type Failer struct {
	Err     error
	Context map[string]any
}

Failer is the public, exported representation of a thrown failure.

A `Failer` preserves the original `error` (Err) and an optional map[string]any Context for arbitrary key/value metadata. The library uses `Failer` values to implement structured panic-based propagation inside trusted internal call chains: callers may `panic` a `Failer` (via `FailWith` or `Failer.Fail`) and a `Check*` boundary will convert that panic back into a returned `error`.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	err := relax.CheckFailer(func() {
		failer := relax.ConvertToFailer(errors.New("repository failed"))

		failer.Fail(
			"repository", "users",
			"operation", "find",
		)
	})

	var failer relax.Failer
	if errors.As(err, &failer) {
		fmt.Println(failer.Err)
		fmt.Println(failer.Context["repository"])
	}

}
Output:
repository failed
users

func ConvertToFailer

func ConvertToFailer(err error) Failer

ConvertToFailer converts any error into a `Failer` value.

If `err` is already a `Failer` (or wraps one), the underlying `Failer` is returned unchanged. Otherwise a new `Failer` wrapping `err` is returned.

Example
package main

import (
	"errors"
	"fmt"

	"github.com/arpaad/relax"
)

func main() {
	err := errors.New("boom")

	failer := relax.ConvertToFailer(err)

	fmt.Println(failer.Err)

}
Output:
boom

func (Failer) Error

func (f Failer) Error() string

Error returns the underlying error message for this Failer.

func (Failer) Fail

func (f Failer) Fail(keyVals ...any)

Fail throw this Failer. If extra key/value pairs are provided, they are merged into the Failer context. This avoids wrapping a Failer inside another Failer when rethrowing.

func (Failer) Unwrap

func (f Failer) Unwrap() error

Unwrap returns the underlying error for compatibility with errors.As and errors.Is.

Jump to

Keyboard shortcuts

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