Exceptions
> Structured error handling with rich dev pages, safe production responses, content negotiation, and pluggable reporters.
The exceptions package turns errors returned from handlers into
correctly negotiated HTTP responses - verbose stack traces in
development, safe minimal responses in production - and runs reporters
so errors reach your logs or monitoring service.
Import path: github.com/velocitykode/velocity/exceptions
The Handler
*exceptions.Handler is the central type. Velocity constructs one in
velocity.New() and stores it at app.Services.Exceptions. You can
reconfigure it via v.Exceptions(...) during bootstrap. The callback
receives the handler as an exceptions.ExceptionHandler interface,
which exposes the configuration methods (AddReporter,
AddRenderer, SetReporters, DontReport, SetAPIMode,
SetAPIPrefixes, RegisterCustomHandler, SetDebug, …).
v.Exceptions(func(h exceptions.ExceptionHandler) {
// add reporters, renderers, custom handlers
})Build one yourself with NewHandler(opts...):
h := exceptions.NewHandler(
exceptions.WithDebug(true),
exceptions.WithEnvironment("development"),
)Debug mode is force-disabled in production regardless of the
WithDebug flag - the handler logs a warning and continues without
exposing stack traces.
Available options
| Option | Effect |
|---|---|
WithDebug(bool) | Include stack traces and source context in responses |
WithEnvironment(string) | Environment name; "production" force-disables debug |
WithReporters(...Reporter) | Replace the default log reporter |
WithRenderers(map[string]Renderer) | Merge in renderers by content type (overrides the "html"/"json" defaults) |
WithDontReport(...string) | Exception type names that should be silenced |
WithAPIMode(bool) | Always respond with JSON |
WithAPIPrefixes(...string) | URL prefixes treated as API routes (JSON by default) |
WithTrustedProxies([]*net.IPNet) | Proxy networks whose forwarded headers may be honoured when capturing the client IP (nil = ignore forwarded headers) |
WithHandlerLogger(Logger) | Logger used for internal handler warnings |
The Exception interface
Any error satisfying Exception participates in the exception
lifecycle:
type Exception interface {
error
GetMessage() string
GetCode() int
GetPrevious() error
GetContext() map[string]any
}Two optional interfaces opt in to more behavior:
Reportable- returns whether the exception should be sent to reportersRenderable- renders its own response
BaseException
*BaseException is the starter implementation. Use it directly or
embed it in a custom type:
e := exceptions.NewBaseException("payment declined", 402).
WithPrevious(err).
WithContext("user_id", user.ID).
WithContext("order_id", order.ID)Context data is serialized into dev-mode responses and passed to reporters.
HTTP-specific exceptions
Preconstructed exceptions cover the common HTTP failure cases. Each carries a status code and a sensible default message; most accept an optional override:
| Constructor | Status |
|---|---|
NewHttpException(code, msg) | any |
NewBadRequestHttpException(msg?) | 400 |
NewUnauthorizedHttpException(msg?) | 401 |
NewForbiddenHttpException(msg?) | 403 |
NewNotFoundHttpException(msg?) | 404 |
NewMethodNotAllowedHttpException(methods, msg?) | 405 |
NewConflictHttpException(msg?) | 409 |
NewGoneHttpException(msg?) | 410 |
NewTooManyRequestsException(retryAfter, msg?) | 429 |
NewInternalServerErrorException(msg?) | 500 |
NewServiceUnavailableException(retryAfter, msg?) | 503 |
NewValidationException(errors, msg?) | 422 |
All 4xx HTTP exceptions return false from ShouldReport() - they
aren’t surfaced to reporters by default. 5xx and non-HTTP exceptions
report by default.
Attach headers with .WithHeader() or .WithHeaders():
return exceptions.NewTooManyRequestsException(60, "slow down").
WithHeader("Retry-After", "60")Abort helpers
Three free functions let you bail out of a handler concisely:
func Show(c *router.Context) error {
post, err := models.FindPost(c.Param("id"))
if err != nil {
return exceptions.Abort(http.StatusNotFound, "post not found")
}
if err := exceptions.AbortUnless(post.Published, http.StatusForbidden, "draft"); err != nil {
return err
}
return c.JSON(http.StatusOK, post)
}Abort(code, msg?)- always returns anHttpExceptionAbortIf(condition, code, msg?)- exception when condition istrue, elsenilAbortUnless(condition, code, msg?)- exception when condition isfalse, elsenil
ValidationException
return exceptions.NewValidationException(map[string][]string{
"email": {"must be a valid email"},
"password": {"must be at least 8 characters"},
}, "the given data was invalid")Rendered as a 422 response with the error map serialized alongside the
top-level message. Use this type when you need to surface per-field
validation errors without running the full validation pipeline.
Content negotiation
The handler picks a renderer based on the request:
WithAPIMode(true)- always JSON.- Request path matches any
WithAPIPrefixesentry - JSON. Acceptheader containsapplication/json- JSON.Content-Typecontainsapplication/json- JSON.X-Requested-With: XMLHttpRequest- JSON.- Request path starts with
/api- JSON. - Otherwise - HTML.
Swap the defaults with WithRenderers(...) if you need a custom
serialization (protobuf, XML) or a templated HTML error page.
Custom handlers per type
Register behavior for specific error types with
RegisterCustomHandler(exceptionType any, handler func(RenderContext, error, *ExceptionContext)).
The first argument is matched by runtime type (reflect.TypeOf), so
pass a value of the concrete error type you want to intercept:
v.Exceptions(func(h exceptions.ExceptionHandler) {
h.RegisterCustomHandler(&mypkg.RecordNotFoundError{}, func(ctx exceptions.RenderContext, err error, ec *exceptions.ExceptionContext) {
ctx.WriteHeader(http.StatusNotFound)
ctx.Write([]byte(`{"error":"not found"}`))
})
})The handler consults this registration before falling back to the
default renderer. Note that the type is matched exactly: an error must
be of the same concrete type (after Renderable is checked) for its
custom handler to fire.
Reporters
Reporters are called for every exception that opts into reporting.
The default is LogReporter - writes a structured log line via the
app’s logger.
type Reporter interface {
Report(err error, ctx *ExceptionContext)
}Build your own by implementing the interface:
type SentryReporter struct { /* ... */ }
func (s *SentryReporter) Report(err error, ctx *exceptions.ExceptionContext) {
sentry.CaptureException(err, sentry.WithExtras(ctx.Extra))
}
v.Exceptions(func(h exceptions.ExceptionHandler) {
h.AddReporter(&SentryReporter{})
})For lightweight side-effects, use NewCallbackReporter(fn) where fn
has the signature func(err error, ctx *exceptions.ExceptionContext).
NewMultiReporter(...Reporter) fans a single error out to several
reporters. The built-in LogReporter accepts options:
WithLogger(log.Logger), WithContextKeys(...string), and
WithoutContext().
ExceptionContext
*ExceptionContext carries request metadata into reporters:
ec := exceptions.NewExceptionContext().
WithRequestInfo("POST", "/checkout", "10.0.0.1", "curl/8.0").
WithIDs("req_123", "trace_abc").
WithUserID(strconv.Itoa(user.ID)).
WithExtra("order_id", order.ID)The request middleware populates this automatically; you only build one directly when reporting outside the request path (e.g. from a queue worker).
Silencing exception types
WithDontReport(types...) matches on the runtime type name. The name
is the bare type identifier with the package prefix and pointer marker
stripped (e.g. a *NotFoundHttpException matches "NotFoundHttpException"):
h := exceptions.NewHandler(
exceptions.WithDontReport(
"NotFoundHttpException",
"ClientCanceledError",
),
)The same list can be appended at runtime with h.DontReport(name).
Reporter calls are skipped; the response is still rendered normally.
Dev-mode pages
When WithDebug(true) is set outside production, the HTML renderer
includes:
- Exception type, message, and code
- Stack trace with source context (5 lines either side of each frame)
- Request method, path, IP, user-agent
- Attached context from
WithContext/WithExtra
In production the response shows only the status text and message.
Middleware integration
The exception middleware is applied automatically when you run
velocity.New(); you rarely wire it yourself. If you’re embedding
Velocity’s handler in a bare net/http server, use:
mw := exceptions.Middleware(h)
srv := &http.Server{Handler: mw(myMux)}For per-handler wrapping use MiddlewareFunc or pass
ErrorHandler(h) anywhere you need a plain func(http.ResponseWriter, *http.Request, error).