horus is a Go error‐handling toolkit that does more than “return an error”
horus captures the what (operation), why (user‐friendly message), where (stack trace), and how (underlying cause and arbitrary key/value details), then lets you:
- Wrap and re‐wrap errors with layered context
- Seamlessly propagate or bail out with
CheckErr(colored/JSON + configurable exit codes) - Plug into any CLI, HTTP handler, or logger with zero ceremony
- Whether you’re building a command‐line tool, a microservice, or a big data pipeline,
horusensures nothing gets lost in translation when things go sideways
Create Herror instances via NewHerror, NewCategorizedHerror, Wrap or WithDetail that carry:
- Operation name (
Op) - Human-readable message (
Message) - Category tag (
Category) - Arbitrary details map (
Details) - Full stack trace
PropagateErrfor idiomatic upstream wrappingRootCause(err)to peel back nested failures- Helpers like
AsHerror,IsHerror,Operation,UserMessage,GetDetail,Category,StackTrace
err := doSomething()
// only if err != nil it gets wrapped
return horus.PropagateErr("DoSomething", "SERVICE", "failed", err, nil)JSONFormatterfor structured logsPseudoJSONFormatterfor aligned, colorized tables in your terminalPlainFormatterorSimpleColoredFormatterfor minimal output
CheckErr(err, opts...)writes formatted error to your choice ofio.Writerand exits with a customizable code- Built-in overrides: writer, formatter, exit code, operation, category, message, details
horus.CheckErr(err) // default: colored table + os.Exit(1)
horus.CheckErr(err, horus.WithWriter(os.Stdout), horus.WithExitCode(42))LogNotFound/NullActionimplementNotFoundActionfor pluggable “resource missing” behaviors- Fully testable via
WithLogWriter
act := horus.LogNotFound("cache miss")
resolved, err := act("user:123")
// resolved==false, err==nilCollectingError(implementsio.Writer+error) to capture and inspect output in tests- Easy use of
WithWriter(buf)to drive deterministic output
Panic(op, msg)logs a colored panic banner, captures a stack, then panics with a fullHerrorpayload
package main
import (
"errors"
"fmt"
"github.com/DanielRivasMD/horus"
)
func loadConfig(path string) error {
// pretend this fails
return errors.New("file not found")
}
func main() {
err := loadConfig("/etc/app.cfg")
if err != nil {
// wrap with context, category, and detail
wrapped := horus.NewCategorizedHerror(
"LoadConfig",
"IO_ERROR",
"unable to load configuration",
err,
map[string]any{"path": "/etc/app.cfg"},
)
// print a pretty, colored table to stderr and exit
horus.CheckErr(wrapped)
}
fmt.Println("config loaded")
}| Language | Command |
|---|---|
| Go | go get github.com/DanielRivasMD/horus@latest |
import "github.com/DanielRivasMD/horus"The horus error-handling library provides a set of powerful functions to wrap, propagate, log, and format errors across your application. Here’s how you can leverage these functions at various layers:
Wrap errors as soon as they occur
For example, when reading a configuration file:
package fileutils
import (
"fmt"
"os"
"github.com/DanielRivasMD/horus"
)
// ReadConfig tries to read a JSON config from disk.
// On failure it wraps the underlying error with full context, category and details.
func ReadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, horus.PropagateErr(
"ReadConfig", // Op
"IO_ERROR", // Category
fmt.Sprintf("unable to load config"), // Message
err, // underlying error
map[string]any{ // Details
"path": path,
},
)
}
return data, nil
}What this does:
- Uses Go 1.16+’s
os.ReadFileinstead of the deprecatedioutil - Always returns
nilor a rich*Herror, never a rawerror - Stamps on:
Op= "ReadConfig"Category= "IO_ERROR"Message= a user-friendly "unable to load config"Details= {"path": path}Stacktrace (captured at the call site)
You’ll now get output like:
Op ReadConfig,
Message unable to load config,
Err open /etc/app.cfg: no such file or directory,
path /etc/app.cfg,
Category IO_ERROR,
Stack
fileutils.ReadConfig()
/Users/.../fileutils/config.go:12
main.main()
/Users/.../cmd/app/main.go:23
runtime.main()
/usr/local/go/src/runtime/proc.go:250
...
and if you prefer JSON:
horus.CheckErr(err, horus.WithFormatter(horus.JSONFormatter))will emit something like:
{
"Op": "ReadConfig",
"Message": "unable to load config",
"Err": "open /etc/app.cfg: no such file or directory",
"Details": { "path": "/etc/app.cfg" },
"Category": "IO_ERROR",
"Stack": [ ... ]
}When higher-level functions catch errors from lower‐level routines, add domain‐specific context with PropagateErr (or WithDetail) so every layer contributes its own clues:
package business
import (
"github.com/your_module/fileutils"
"github.com/DanielRivasMD/horus"
)
// LoadAndProcessConfig orchestrates reading + validating your config.
// Any I/O or parse failures get wrapped with step-specific context.
func LoadAndProcessConfig(configPath string) error {
// 1. Call the fileutils helper
data, err := fileutils.ReadConfig(configPath)
if err != nil {
// PropagateErr merges the underlying category/details and stamps on new ones.
return horus.PropagateErr(
"LoadAndProcessConfig", // operation name
"CONFIG_ERROR", // business category
"unable to load application config", // user-friendly message
err, // the error from ReadConfig
map[string]any{ // extra details
"path": configPath,
"service": "business",
},
)
}
// 2. (Optional) add validation context
if len(data) == 0 {
// NewHerror creates a fresh Herror; WithDetail would wrap an existing one.
return horus.NewHerror(
"LoadAndProcessConfig",
"config data is empty",
nil,
map[string]any{"path": configPath},
)
}
// 3. process the config...
return nil
}PropagateErrautomatically carries forward any category or details from the lower layer (and merges the new payload)- We choose a “business” category ("CONFIG_ERROR") that’s orthogonal to the lower-level "IO_ERROR"
- We include both
pathand our own service:"business" detail. - For purely business‐rule failures (like empty data), we use
NewHerrorto start a fresh error.
At the top‐level ofthe app - usually in main() - CheckErr can be use as one‐stop fatal error handler:
- Format the error (colored table by default) or JSON if you prefer
- Register the error’s category in a global registry (for metrics/observability)
- Exit with a configurable code (default: 1)
package main
import (
"os"
"github.com/DanielRivasMD/horus"
"github.com/your_module/business"
)
func main() {
// Run your business logic
err := business.LoadAndProcessConfig("config.json")
if err != nil {
// Default: colored table → stderr, exit code 1
horus.CheckErr(err)
// Or JSON + code 2 + log to stdout:
// horus.CheckErr(
// err,
// horus.WithFormatter(horus.JSONFormatter),
// horus.WithExitCode(2),
// horus.WithWriter(os.Stdout),
// )
}
// Continue with normal execution...
}Under the hood, CheckErr does:
RegisterError(err)– increments a counter for your error’s categoryfmt.Fprintln(writer, formatter(err))– prints your chosen formatexitFunc(code)– callsos.Exit(code)by default
This ensures that all unhandled, fatal errors flow through a consistent, observable pipeline
For commands executed via external processes (e.g., running system commands), use functions like ExecCmd or CaptureExecCmd, horus:
- Shows both
ExecCmd(streams output) andCaptureExecCmd(buffers it) - Wraps errors with
PropagateErrfor context before callingCheckErr - Logs stdout/stderr details in your error’s
Detailsmap
package domovoi
import (
"fmt"
"github.com/DanielRivasMD/horus"
)
// ListDirectory runs `ls -la <path>` twice: once streamed, once captured.
// It returns an error if anything fails; caller can then CheckErr(err).
func ListDirectory(path string) error {
// 1) Stream mode
if err := ExecCmd("ls", "-la", path); err != nil {
// ExecCmd already wraps in *Herror, but we can add our own op/category
return horus.NewCategorizedHerror(
"ListDirectory", // Op
"SYS_CMD", // Category
fmt.Sprintf("ls -la %s", path), // Message
err,
nil, // no extra details here
)
}
// 2) Capture mode
stdout, stderr, err := CaptureExecCmd("ls", "-la", path)
if err != nil {
// We got a *Herror from CaptureExecCmd—merge in the captured output
return horus.PropagateErr(
"ListDirectory",
"SYS_CMD",
"failed to capture ls output",
err,
map[string]any{"stdout": stdout, "stderr": stderr},
)
}
fmt.Println("=== STDOUT ===")
fmt.Print(stdout)
fmt.Println("=== STDERR ===")
fmt.Print(stderr)
return nil
}ExecCmd(op, category, message, *exec.Cmd)runs and logs failures immediatelyCaptureExecCmdreturns(stdout, stderr string, err error)- We propagate errors with
PropagateErrso our top‐levelCheckErrshows the full story: operation, category, message, underlying cause, stdout, stderr, and stack trace
When wrapping system calls like os.Chdir, use Horus to enrich and propagate errors with full context:
package domovoi
import (
"fmt"
"os"
"github.com/DanielRivasMD/horus"
)
// ChangeDirectory attempts to chdir into the given path.
// On failure it wraps the underlying os.Chdir error with operation,
// category, user-friendly message, and the path in Details.
func ChangeDirectory(path string) error {
if err := os.Chdir(path); err != nil {
return horus.PropagateErr(
"ChangeDirectory", // Op
"FS_ERROR", // Category
fmt.Sprintf("unable to change working directory"),// Message
err, // underlying error
map[string]any{"path": path}, // Details
)
}
return nil
}Op= "ChangeDirectory"Category= "FS_ERROR"Message= "unable to change working directory"Details= {"path": "/some/dir"}Stacktrace captured at the call site
That way, any failure bubbles up as a full *Herror - complete with stack, category, and details—making your logs and CLI output immediately actionable
Build from source:
git clone https://github.com/DanielRivasMD/horus
cd horus| Language | Dev Dependencies | Hot Reload |
|---|---|---|
| Go | go >= 1.22 |
air (live reload) |
Copyright (c) 2025
See the LICENSE file for license details.