Errors
Anvil normalizes errors into sdk.Failure.
Handlers still return ordinary Go error values. The generated route and driver
code attaches route context, maps the error through the global pipeline, emits
an observer event, and lets the driver convert the failure into a
protocol-native response.
For a request handled through the shared edge listener, an error moves through this path:
- The request reaches Anvil’s edge listener.
- The edge selects HTTP, GraphQL, or gRPC.
- The selected driver creates the protocol context.
- Generated code binds and validates the request when the route has a request model.
- Middleware and the handler run.
- Binding, decoding, middleware, handler execution, response encoding, transport work, or panic recovery returns an error.
- Anvil builds
sdk.ErrorContext. - The error pipeline maps the error to
sdk.Failure. - Observers registered with
app.OnErrorreceivesdk.ErrorEvent. - The driver maps
sdk.Failureto the protocol response.
The application controls domain classification by returning sdk.Failure
values through ctx.Errors() or registering custom sdk.ErrorMapper
implementations.
Queue drivers use the same mapping pipeline after a broker message has been
converted to sdk.QueueMessage. There is no edge listener involved for queue
delivery.
sdk.Failure
Section titled “sdk.Failure”type Failure struct { Status int Message string Fields map[string]string Attrs map[string]any Cause error Context ErrorContext Stack []Frame Expected bool}Field behavior:
Statusis a standard HTTP status code.Messageis the public message. Keep it safe to send to a client.Fieldsstores request field errors for validation failures and invalid parameters.Attrsstores additional structured metadata for internal observers.Causepreserves the technical error forerrors.Isanderrors.As.Contextidentifies where Anvil saw the error.Stackis captured for unexpected internal failures.Expectedseparates domain/client failures from technical failures.
Failure.Error() intentionally does not print Cause, because database errors,
cloud SDK errors, SQL text, or secrets can appear there.
When Message is empty, Failure.Error() returns internal server error for
status 0 or 500, lower-case HTTP status text for known non-500 statuses,
and request failed when Go has no text for that status.
Before observers or drivers see a failure, the pipeline normalizes it:
- Status codes outside
100..599become500. - Status
500always becomes public messageinternal server error. - Status
500always setsExpectedtofalse. - Missing non-500 messages fall back to
http.StatusText(status)orrequest failed. - Missing
Causeis filled with the original error. Fields,Attrs, andContext.Attrsare initialized to empty maps.- Internal failures receive stack frames when none were already supplied.
Statuses
Section titled “Statuses”Anvil does not define its own error-code enum. Use net/http status constants:
return nil, ctx.Errors().Failure(http.StatusUnauthorized, "missing authorization")HTTP drivers send the status directly. The gRPC driver maps common statuses to
matching gRPC codes. The GraphQL driver exposes the status as
extensions.status.
Internal failures are normalized to internal server error before they reach
clients. Put technical details in the wrapped cause or failure attributes for
observers.
Error Context
Section titled “Error Context”type ErrorContext struct { Protocol string Controller string Endpoint string Method string Route string Path string RequestID string TraceID string Phase ErrorPhase Attrs map[string]any}Phases are bounded to avoid high-cardinality observability data:
sdk.ErrorPhaseBindsdk.ErrorPhaseDecodesdk.ErrorPhasePolicysdk.ErrorPhaseHandlersdk.ErrorPhaseEncodesdk.ErrorPhaseTransportsdk.ErrorPhasePanicDrivers and generated code fill the protocol, controller, endpoint, method,
route, path, and phase when those values are known. Custom mappers can return a
failure with RequestID, TraceID, or Attrs; the pipeline merges that
context with the driver context before publishing the event.
WebSocket and streaming handlers still use the same bounded phases. For example, a socket handler error is reported as handler phase, and a recovered socket panic is reported as panic phase.
Creating Expected Errors
Section titled “Creating Expected Errors”Use ctx.Errors() in handlers and middleware:
if actor == nil { return nil, ctx.Errors().Failure(http.StatusUnauthorized, "missing authorization")}
if project.IsNotFound(err) { return ProjectDTO{}, ctx.Errors().NotFound("project")}Expected errors set Expected: true. Error observers can use that flag to
avoid sending ordinary client mistakes to Sentry.
Failure(status, message) accepts standard HTTP status codes. Invalid status
codes are normalized to 500. A normalized 500 is internal, captures stack
frames, and sets Expected: false; valid non-500 statuses are expected
failures.
NotFound(resource) returns 404 with message "<resource> not found".
An empty resource name becomes "resource".
Validation Errors
Section titled “Validation Errors”Prefer declarative tags for field-level checks:
type CreateProjectRequest struct { Name string `json:"name" validate:"required,min=2,max=80"`}Use request-level validation only for cross-field or domain checks:
func (req ScheduleRequest) Validate(ctx sdk.Ctx) error { if req.StartsAt.After(req.EndsAt) { return ctx.Errors().Validation(). Field("startsAt", "must be before end"). Err() } return nil}Validation().Err() returns a 400 Bad Request failure with the accumulated
field messages. Empty field names are ignored. Empty messages become
invalid value.
InvalidParam(name, cause) also returns a 400 Bad Request failure. The
public message is invalid request, the field entry is name: invalid value,
and cause is kept for observers. An empty param name becomes param.
Wrapping Technical Errors
Section titled “Wrapping Technical Errors”Use Wrap when a lower-level dependency returns an unexpected error:
created, err := p.Store.Create(ctx.Context(), input)if err != nil { return ProjectDTO{}, ctx.Errors().Wrap(err, "creating project")}Wrap produces an internal failure with:
- Public message:
internal server error - Cause:
fmt.Errorf("creating project: %w", err) - Stack frames
- Generated route context
Observers can inspect event.Error, event.Failure.Cause, and
event.Failure.Stack. Clients receive the public failure message, not the
technical cause.
If Wrap receives a nil cause, Anvil records a missing cause error. If the
operation string is empty, it records the operation as operation.
Custom Mappers
Section titled “Custom Mappers”Register mappers when the application already has a domain error vocabulary:
type DomainMapper struct{}
func (DomainMapper) MapError(ctx sdk.ErrorContext, err error) (sdk.Failure, bool) { if project.IsArchived(err) { return sdk.Failure{ Status: http.StatusPreconditionFailed, Message: "project is archived", Context: ctx, Expected: true, }, true } return sdk.Failure{}, false}
app.ErrorPipeline().Use(DomainMapper{})Mappers run in registration order. The first mapper that returns true wins.
Replace swaps the fallback mapper when the application wants full control over
classification.
The fallback mapper treats *sdk.Failure as already classified. Any other Go
error becomes an internal failure. Mapper output still goes through the same
normalization step, so a mapper cannot accidentally expose a custom 500
message.
When both the driver context and mapper-supplied failure context contain a field, the mapper-supplied field wins. Attribute maps are merged.
Observers
Section titled “Observers”Observers see the mapped failure after Anvil has merged route context:
app.OnError(func(ctx context.Context, event sdk.ErrorEvent) { if event.Expected { metrics.Count("anvil.expected_error", event.Failure.Status) return }
reporter.CaptureException(ctx, event.Error, map[string]any{ "status": event.Failure.Status, "controller": event.Failure.Context.Controller, "endpoint": event.Failure.Context.Endpoint, "phase": event.Failure.Context.Phase, "route": event.Failure.Context.Route, })})sdk.ErrorEvent contains:
Error: the normalized failure causeFailure: the normalized failureExpected: whether this is a domain/client failureRecovered: whether it came from panic recovery
event.Error and event.Failure.Cause come from the same normalized failure.
For internal failures created by Wrap, that is the wrapped technical cause.
For a custom mapper that returns a failure without a cause, Anvil fills the
cause with the original error being mapped. For a ctx.Errors() failure
without an explicit cause, the original mapped error is the failure value
itself.
Observers run synchronously in registration order. They have no error return. If an observer writes to Sentry, Datadog, a log sink, or another external system, put timeouts and panic recovery inside the observer.
Anvil does not recover observer panics. Treat observers like production request code: keep them short, handle their own failures, and move slow external work behind a queue or buffered reporter when needed.
Panic Recovery
Section titled “Panic Recovery”Handlers and middleware return errors for normal failures. Drivers recover panics at protocol boundaries so one bad request does not stop the process.
First-party driver behavior:
| Driver path | Recovered panic behavior |
|---|---|
anvil-http/std HTTP routes | Mapped as 500, phase panic, event.Recovered == true |
anvil-http/std WebSockets | Mapped as 500, phase panic, event.Recovered == true |
anvil-http/fiber HTTP routes | Mapped as 500, phase panic, event.Recovered == true |
anvil-http/fiber WebSockets | Mapped as 500, phase panic, event.Recovered == true |
anvil-graphql execution and subscriptions | Recovered into a handler-phase error |
anvil-grpc unary and streaming handlers | Recovered into a handler-phase error and converted to gRPC status |
anvil-queue job middleware and handlers | Recovered into a handler-phase error and returned to the driver delivery path |
event.Recovered is computed from failure.Context.Phase == sdk.ErrorPhasePanic.
That means it is true for the HTTP-family panic paths above and false for the
drivers that recover panics into handler-phase errors.
A recovered panic fails only the current request, socket, RPC, subscription, or queue delivery. It does not stop the process.