svcerr

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 10 Imported by: 0

README

svcerr

CI

Typed application errors for Go services: error codes, HTTP status mapping, JSON/HTML response writers, panic-recovery middleware, and stack trace capture. The core package imports no logging library — see "Logging" below.

import "github.com/n-ae/svcerr"

Why

Handlers need to turn an error into the right HTTP status, a safe user-facing message, and a structured log line, without leaking internals (raw SQL, stack traces, third-party error text) into the response. svcerr does that mapping once, centrally, instead of every handler improvising it.

Usage

func (s *Service) GetLeague(id string) (*League, error) {
	var league League
	err := s.db.QueryRow(`SELECT * FROM leagues WHERE id = ?`, id).Scan(&league)
	if err == sql.ErrNoRows {
		return nil, svcerr.NewNotFoundError("league", id)
	}
	if err != nil {
		return nil, svcerr.WrapDatabaseError(err, "query", "SELECT * FROM leagues...")
	}
	return &league, nil
}

func (h *Handler) GetLeague(w http.ResponseWriter, r *http.Request) {
	league, err := h.service.GetLeague(r.URL.Query().Get("id"))
	if err != nil {
		// h.logger implements svcerr.Logger - see "Logging" below
		svcerr.WriteHTTPError(w, err, h.logger) // maps to the right status, logs, writes JSON
		return
	}
	json.NewEncoder(w).Encode(league)
}

WriteHTTPError writes a JSON body:

{
  "error": {
    "code": "NOT_FOUND",
    "message": "league not found: 12345",
    "details": { "resource_type": "league", "resource_id": "12345" }
  }
}

WriteHTTPErrorHTML writes an HTML fragment instead, for HTMX-style endpoints. WriteHTTPProblem writes an RFC 9457 application/problem+json body instead, for clients that expect the standard problem-details shape:

{
  "type": "about:blank",
  "title": "The requested resource was not found.",
  "status": 404,
  "detail": "league not found: 12345",
  "code": "NOT_FOUND",
  "resource_type": "league",
  "resource_id": "12345"
}

title is the generic, code-level description; detail is specific to this occurrence (and follows the same public/internal message rules as WriteHTTPError - see "Public vs. internal messages" below). Extension members (code, resource_type, resource_id, ...) sit at the top level, per RFC 9457, rather than nested under a sub-object.

UserMessage(err) returns just the sanitized message, for callers embedding it in a custom response.

Check error types with stdlib errors.As — there's no per-type IsXError wrapper. svcerr is a distinct package name (not errors), so both imports coexist without an alias:

import (
	"errors"

	"github.com/n-ae/svcerr"
)

var nfErr *svcerr.NotFoundError
if errors.As(err, &nfErr) {
	// ...
}

Recover panics in HTTP handlers and turn them into a proper error response:

router.Use(svcerr.RecoveryMiddleware(h.logger))

RecoveryMiddleware tracks whether the handler already wrote a response before panicking, and won't write an error body over one that's already committed - it just logs in that case. It also passes through http.Flusher and http.Hijacker, so SSE handlers and WebSocket upgrades still work for handlers wrapped by the middleware; a successful hijack is itself treated as committing the response.

Error types

ValidationError, DatabaseError, ExternalAPIError, AuthenticationError, NotFoundError, ConflictError, RateLimitError, InternalError — each has a New*/Wrap* constructor, carries an ErrorCode, and supports stdlib errors.Is/errors.As/errors.Unwrap in the usual way. See the package doc comment in errors.go for the full list of codes and their HTTP status mapping.

For a code with no dedicated constructor (e.g. ErrCodeDatabaseConnection, ErrCodeMissingRequired, ErrCodeResourceConflict, ErrCodeQuotaExceeded), use the generic New/Wrap:

err := svcerr.New(svcerr.ErrCodeDatabaseConnection, "could not reach the database")
err := svcerr.Wrap(dbErr, svcerr.ErrCodeDatabaseConnection, "could not reach the database")

For an application-specific code entirely outside this package's built-in set, register its HTTP status once, at startup:

const ErrCodeOutOfStock svcerr.ErrorCode = "OUT_OF_STOCK"

func init() {
	svcerr.RegisterStatusCode(ErrCodeOutOfStock, http.StatusConflict)
}

RegisterStatusCode can also override a built-in code's mapping, for a deployment that wants different semantics than the default.

Custom error types

You don't have to embed BaseError to plug into this package - implement just the capability you need:

type Coder interface {
	Code() ErrorCode // GetErrorCode, HTTPStatusCode
}

type StackTracer interface {
	StackTrace() []string // GetStackTrace
}

type PublicMessager interface {
	PublicMessage() (string, bool) // getUserFriendlyMessage / UserMessage overrides
}

A minimal type implementing Coder (and error) is enough to get correct status-code mapping from WriteHTTPError/WriteHTTPProblem; add Unwrap() error if you also want the "don't leak a wrapped cause" safety property. ErrorWithCode is the full combination BaseError-derived types implement (plus StackTracer), kept as a single name for convenience - it's not a requirement for participating in the package's functions.

Public vs. internal messages

By default the client-facing message is the error's own Error() text - but only when the error has no wrapped cause. An error built by a Wrap* constructor (or Wrap) falls back to a generic per-code message instead, since its Error() text embeds the wrapped cause and may carry detail (a raw query, an internal hostname, third-party error text) you'd log but never want in a response. SetPublicMessage overrides the client-facing text explicitly, for either case:

err := svcerr.WrapDatabaseError(dbErr, "query", "SELECT * FROM leagues...")
err.SetPublicMessage("We're having trouble reaching the database. Please try again shortly.")
return err // WriteHTTPError/UserMessage now send the override; logs still get err.Error()
Wrapping constructors in your own helper

Every New*/Wrap* constructor assumes it's called directly from the site its stack trace should point at. If you wrap one in your own helper (e.g. a project-wide validation function), the trace ends up pointing at the helper instead of its caller. Fix that with RecaptureStackTrace, called from inside the helper right after construction:

func validateTeamID(id string) error {
	if id == "" {
		err := svcerr.NewValidationError("team_id is required", "team_id", nil)
		svcerr.RecaptureStackTrace(err, 1) // point past this helper
		return err
	}
	return nil
}
Logging

WriteHTTPError, WriteHTTPErrorHTML, WriteHTTPProblem, and RecoveryMiddleware log through a minimal Logger interface, not a specific logging library:

type Logger interface {
	Log(level Level, err error, fields map[string]interface{}, msg string)
}

Using zerolog? Wrap it with the zerologadapter subpackage instead of writing your own adapter:

import "github.com/n-ae/svcerr/zerologadapter"

svcerr.WriteHTTPError(w, err, zerologadapter.New(logger))

zerologadapter is optional and lives in this same module - it's the one depending on zerolog, not the core svcerr package. Importing it is what pulls zerolog into your build; if you don't, you don't get it.

For any other logger, implement the one-method Logger interface directly.

Origin

Extracted from an application's internal/errors package once it had no callers left depending on app-specific behavior — see the release notes for what changed along the way.

Documentation

Overview

Package svcerr provides custom error types for consistent error handling.

Error types: ValidationError, DatabaseError, ExternalAPIError, AuthenticationError, NotFoundError, ConflictError, RateLimitError, InternalError.

All types implement ErrorWithCode interface and support error wrapping. A custom error type doesn't need to embed BaseError to participate - implementing just Coder, StackTracer, or PublicMessager independently is enough for the corresponding functions (GetErrorCode/HTTPStatusCode, GetStackTrace, and the public-message overrides) to recognize it.

This package's own code imports no logging library: WriteHTTPError, WriteHTTPErrorHTML, and RecoveryMiddleware log through the Logger interface instead - pass an adapter for whatever logger the caller uses. (The zerologadapter subpackage, which does depend on zerolog, is optional and lives in this same module; importing it is what pulls zerolog into a caller's build, not this package.)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func GetStackTrace

func GetStackTrace(err error) []string

GetStackTrace extracts the stack trace from an error. It only requires StackTracer, not the full ErrorWithCode.

func HTTPStatusCode

func HTTPStatusCode(code ErrorCode) int

HTTPStatusCode maps error codes to HTTP status codes

func RecaptureStackTrace added in v0.2.1

func RecaptureStackTrace(err error, extraSkip int)

RecaptureStackTrace re-captures err's stack trace starting extraSkip frames higher than the normal New*/Wrap* capture point. Every constructor in this package assumes it's called directly from the site the trace should point at; if you wrap a constructor in your own helper function, the trace ends up pointing at that helper instead of its caller. Call RecaptureStackTrace(err, 1) from inside such a helper, immediately after constructing err, to fix that - err must be one of this package's error types (or wrap one); otherwise this is a no-op.

func RecoveryMiddleware

func RecoveryMiddleware(logger Logger) func(http.Handler) http.Handler

Middleware for error recovery and logging

func RegisterStatusCode added in v0.3.0

func RegisterStatusCode(code ErrorCode, status int)

RegisterStatusCode adds or overrides the HTTP status HTTPStatusCode returns for code. Use it to extend the built-in mapping with an application's own ErrorCode values (constructed via New/Wrap), or to override a built-in mapping for a deployment that wants different semantics. Safe for concurrent use.

func UserMessage added in v0.1.1

func UserMessage(err error) string

UserMessage returns the safe, user-facing message for an error - the same sanitized text WriteHTTPError/WriteHTTPErrorHTML send (e.g. a wrapped database error's raw cause is never included), for callers that need to embed it in a custom response fragment instead of one of those two standard bodies.

func WriteHTTPError

func WriteHTTPError(w http.ResponseWriter, err error, logger Logger)

WriteHTTPError writes a standardized error response to the HTTP response writer

func WriteHTTPErrorHTML

func WriteHTTPErrorHTML(w http.ResponseWriter, err error, logger Logger)

WriteHTTPErrorHTML writes an HTML error response (for non-API endpoints)

func WriteHTTPProblem added in v0.3.0

func WriteHTTPProblem(w http.ResponseWriter, err error, logger Logger)

WriteHTTPProblem writes an RFC 9457 "application/problem+json" error response - an alternative body shape to WriteHTTPError's own {"error": {...}} for callers whose clients expect the standard problem-details format. Status mapping, message safety (Detail never includes a wrapped cause's text without an explicit SetPublicMessage), and logging behave identically to WriteHTTPError.

Types

type AuthenticationError

type AuthenticationError struct {
	BaseError
	SessionID string
	Reason    string // "token_expired", "token_invalid", "permission_denied"
}

AuthenticationError represents authentication and authorization errors

func NewAuthenticationError

func NewAuthenticationError(reason, message string) *AuthenticationError

NewAuthenticationError creates a new authentication error

type BaseError

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

BaseError provides common error functionality

func New added in v0.3.0

func New(code ErrorCode, message string) *BaseError

New creates a generic error with the given code and message. Prefer the semantic constructors below (NewValidationError, NewNotFoundError, ...) when one exists for what you're representing; use New directly for codes that have no dedicated constructor, e.g. ErrCodeMissingRequired, ErrCodeDatabaseConnection, ErrCodeDatabaseTransaction, ErrCodeDatabaseMigration, ErrCodeResourceConflict, or ErrCodeQuotaExceeded.

func Wrap added in v0.3.0

func Wrap(err error, code ErrorCode, message string) *BaseError

Wrap wraps err as a generic error with the given code and message. As with the semantic Wrap* constructors, err's text is never shown to clients unless SetPublicMessage is called explicitly.

func (*BaseError) Code

func (e *BaseError) Code() ErrorCode

Code returns the error code

func (*BaseError) Context

func (e *BaseError) Context() map[string]interface{}

Context returns a copy of the error context - callers can't mutate the error's internal state through the returned map.

func (*BaseError) Error

func (e *BaseError) Error() string

Error implements the error interface

func (*BaseError) PublicMessage added in v0.2.1

func (e *BaseError) PublicMessage() (string, bool)

PublicMessage returns the message set by SetPublicMessage, and whether one was set at all.

func (*BaseError) SetPublicMessage added in v0.2.1

func (e *BaseError) SetPublicMessage(msg string)

SetPublicMessage overrides the message WriteHTTPError, WriteHTTPErrorHTML, and UserMessage show the client for this error instance, so the logged Error() text (which may carry internal detail) and the client-facing text can differ. Unset by default, in which case those functions fall back to their normal behavior (the error's own message, or a default per-code message).

func (*BaseError) StackTrace

func (e *BaseError) StackTrace() []string

StackTrace returns a copy of the captured stack trace - callers can't mutate the error's internal state through the returned slice.

func (*BaseError) Unwrap

func (e *BaseError) Unwrap() error

Unwrap implements error unwrapping

type Coder added in v0.3.0

type Coder interface {
	Code() ErrorCode
}

Coder is implemented by any error that carries an application-specific ErrorCode - the minimal capability GetErrorCode and HTTPStatusCode need. A custom error type only needs this one method to participate in status mapping; unlike ErrorWithCode, it doesn't also require Unwrap or StackTrace.

type ConflictError

type ConflictError struct {
	BaseError
	ResourceType string
	ConflictKey  string
}

ConflictError represents resource conflict errors

func NewConflictError

func NewConflictError(resourceType, conflictKey, message string) *ConflictError

NewConflictError creates a new conflict error

type DatabaseError

type DatabaseError struct {
	BaseError
	Operation string // "query", "insert", "update", "delete", "transaction"
	Query     string
}

DatabaseError represents database operation errors

func NewDatabaseError

func NewDatabaseError(operation, message string) *DatabaseError

NewDatabaseError creates a new database error

func WrapDatabaseError

func WrapDatabaseError(err error, operation, query string) *DatabaseError

WrapDatabaseError wraps an existing error as a database error

type ErrorCode

type ErrorCode string

ErrorCode represents application-specific error codes

const (
	// Validation errors (1xxx)
	ErrCodeInvalidInput        ErrorCode = "INVALID_INPUT"
	ErrCodeMissingRequired     ErrorCode = "MISSING_REQUIRED"
	ErrCodeInvalidFormat       ErrorCode = "INVALID_FORMAT"
	ErrCodeConstraintViolation ErrorCode = "CONSTRAINT_VIOLATION"

	// Database errors (2xxx)
	ErrCodeDatabaseConnection  ErrorCode = "DB_CONNECTION"
	ErrCodeDatabaseQuery       ErrorCode = "DB_QUERY"
	ErrCodeDatabaseTransaction ErrorCode = "DB_TRANSACTION"
	ErrCodeDatabaseMigration   ErrorCode = "DB_MIGRATION"

	// External API errors (3xxx). The specific service is carried in
	// ExternalAPIError.Service, not encoded as a separate code per service.
	ErrCodeExternalAPI ErrorCode = "EXTERNAL_API_ERROR"

	// Authentication errors (4xxx)
	ErrCodeUnauthorized     ErrorCode = "UNAUTHORIZED"
	ErrCodeTokenExpired     ErrorCode = "TOKEN_EXPIRED"
	ErrCodeTokenInvalid     ErrorCode = "TOKEN_INVALID"
	ErrCodePermissionDenied ErrorCode = "PERMISSION_DENIED"

	// Resource errors (5xxx)
	ErrCodeNotFound         ErrorCode = "NOT_FOUND"
	ErrCodeAlreadyExists    ErrorCode = "ALREADY_EXISTS"
	ErrCodeResourceConflict ErrorCode = "RESOURCE_CONFLICT"

	// Rate limiting (6xxx)
	ErrCodeRateLimitExceeded ErrorCode = "RATE_LIMIT_EXCEEDED"
	ErrCodeQuotaExceeded     ErrorCode = "QUOTA_EXCEEDED"

	// Internal errors (9xxx). RecoveryMiddleware reports recovered panics
	// as ErrCodeInternal too - there's no separate panic-specific code.
	ErrCodeInternal       ErrorCode = "INTERNAL_ERROR"
	ErrCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED"
)

func GetErrorCode

func GetErrorCode(err error) ErrorCode

GetErrorCode extracts the error code from an error. It only requires Coder, not the full ErrorWithCode - a custom error type that implements just Code() ErrorCode is picked up here (and so by HTTPStatusCode) even if it doesn't also implement Unwrap or StackTrace.

type ErrorDetail

type ErrorDetail struct {
	Code    ErrorCode              `json:"code"`
	Message string                 `json:"message"`
	Details map[string]interface{} `json:"details,omitempty"`
}

ErrorDetail contains detailed error information for API responses

type ErrorWithCode

type ErrorWithCode interface {
	error
	Coder
	Unwrap() error
	StackTracer
}

ErrorWithCode is the full capability set every type in this package implements via BaseError: Coder and StackTracer, plus error and Unwrap. Prefer the narrower Coder, StackTracer, or PublicMessager when writing a custom error type that doesn't want to embed BaseError - GetErrorCode, GetStackTrace, and getUserFriendlyMessage each check the capability they need independently, not this combined interface.

type ExternalAPIError

type ExternalAPIError struct {
	BaseError
	Service    string // caller-defined service name, e.g. "yahoo", "nba_stats"
	StatusCode int
	URL        string
	RetryAfter *int // seconds to retry after; not set by the constructors, assign it directly when known
}

ExternalAPIError represents errors from external APIs

func NewExternalAPIError

func NewExternalAPIError(service, message string, statusCode int, url string) *ExternalAPIError

NewExternalAPIError creates a new external API error

func WrapExternalAPIError

func WrapExternalAPIError(err error, service, url string, statusCode int) *ExternalAPIError

WrapExternalAPIError wraps an existing error as an external API error

type HTTPErrorResponse

type HTTPErrorResponse struct {
	Error ErrorDetail `json:"error"`
}

HTTPErrorResponse represents a standardized HTTP error response

type InternalError

type InternalError struct {
	BaseError
	Component string
}

InternalError represents unexpected internal errors

func NewInternalError

func NewInternalError(component, message string) *InternalError

NewInternalError creates a new internal error

func WrapInternalError

func WrapInternalError(err error, component, message string) *InternalError

WrapInternalError wraps an existing error as an internal error

type Level

type Level int

Level is a log severity, independent of any specific logging library.

const (
	LevelInfo Level = iota
	LevelWarn
	LevelError
)

type Logger

type Logger interface {
	// Log records msg at the given level. err may be nil (e.g. the panic
	// path logs a message with no associated error). fields carries
	// structured context (error_code, http_status, stack_trace, and
	// error-type-specific keys like "field" or "resource_id").
	Log(level Level, err error, fields map[string]interface{}, msg string)
}

Logger is the minimal structured-logging surface this package needs. Nothing in this file imports a logging library - wrap your logger to satisfy this interface. See the zerologadapter subpackage for a zerolog adapter (that subpackage, unlike this one, does depend on zerolog).

type NotFoundError

type NotFoundError struct {
	BaseError
	ResourceType string
	ResourceID   string
}

NotFoundError represents resource not found errors

func NewNotFoundError

func NewNotFoundError(resourceType, resourceID string) *NotFoundError

NewNotFoundError creates a new not found error

type ProblemDetails added in v0.3.0

type ProblemDetails struct {
	Type       string                 // a URI reference identifying the problem type; "about:blank" when none is registered
	Title      string                 // a short, occurrence-invariant summary of the problem type
	Status     int                    // the HTTP status code for this occurrence
	Detail     string                 // a human-readable explanation specific to this occurrence
	Instance   string                 // a URI reference identifying this specific occurrence, if known
	Code       ErrorCode              // this package's own error code, as an extension member
	Extensions map[string]interface{} // additional extension members (e.g. resource_id, field)
}

ProblemDetails is the RFC 9457 (https://www.rfc-editor.org/rfc/rfc9457) "application/problem+json" response body written by WriteHTTPProblem. Code and any extractErrorDetails fields are extension members - RFC 9457 says extension members live at the top level alongside the registered ones, which is what MarshalJSON does instead of nesting them.

func (ProblemDetails) MarshalJSON added in v0.3.0

func (p ProblemDetails) MarshalJSON() ([]byte, error)

MarshalJSON flattens Extensions into the top-level object rather than nesting them under a sub-object, per RFC 9457's extension-member model.

type PublicMessager added in v0.3.0

type PublicMessager interface {
	PublicMessage() (string, bool)
}

PublicMessager is implemented by any error with a client-facing message distinct from its logged Error() text - the minimal capability getUserFriendlyMessage needs to honor SetPublicMessage-style overrides. BaseError (and so every type in this package) implements it via SetPublicMessage/PublicMessage.

type RateLimitError

type RateLimitError struct {
	BaseError
	Service    string
	Limit      int
	RetryAfter int // seconds
}

RateLimitError represents rate limiting errors

func NewRateLimitError

func NewRateLimitError(service string, limit, retryAfter int) *RateLimitError

NewRateLimitError creates a new rate limit error

type StackTracer added in v0.3.0

type StackTracer interface {
	StackTrace() []string
}

StackTracer is implemented by any error that can report a captured stack trace - the minimal capability GetStackTrace needs.

type ValidationError

type ValidationError struct {
	BaseError
	Field string
	Value interface{}
}

ValidationError represents input validation errors

func NewValidationError

func NewValidationError(message string, field string, value interface{}) *ValidationError

NewValidationError creates a new validation error

func WrapValidationError

func WrapValidationError(err error, message string, field string) *ValidationError

WrapValidationError wraps an existing error as a validation error

Directories

Path Synopsis
Package zerologadapter adapts a zerolog.Logger to the svcerr.Logger interface.
Package zerologadapter adapts a zerolog.Logger to the svcerr.Logger interface.

Jump to

Keyboard shortcuts

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